Skip to content

fix(oauth): Honor resource_metadata in WWW-Authenticate fallback - #794

Closed
Gujiassh wants to merge 2 commits into
mark3labs:mainfrom
Gujiassh:fix/oauth-resource-metadata-header
Closed

fix(oauth): Honor resource_metadata in WWW-Authenticate fallback#794
Gujiassh wants to merge 2 commits into
mark3labs:mainfrom
Gujiassh:fix/oauth-resource-metadata-header

Conversation

@Gujiassh

@Gujiassh Gujiassh commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • parse from when protected-resource discovery returns non-200
  • retry protected-resource discovery using that advertised URL before falling back to auth metadata/default endpoints
  • add a regression test for the 401 + resource metadata flow

Validation

Refs #697

Summary by CodeRabbit

  • New Features
    • Improved OAuth discovery with an RFC 9728 fallback that extracts protected-resource metadata from server responses to locate authorization server endpoints when primary discovery fails.
  • Tests
    • Added tests validating the new protected-resource header parsing and end-to-end discovery behavior.

@mark-iii-labs-huly

Copy link
Copy Markdown

Connected to Huly®: MCP_G-362

@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds RFC 9728 fallback to OAuth metadata discovery: on non-200 protected-resource responses, parse WWW-Authenticate for a resource_metadata= URL, fetch the protected-resource JSON from that URL, pick the first authorization_servers entry, and attempt authorization-server discovery from that advertised URL.

Changes

Cohort / File(s) Summary
RFC 9728 OAuth Metadata Discovery Implementation
client/transport/oauth.go
Adds parsing of WWW-Authenticate for resource_metadata= and logic to GET/decode the referenced protected-resource JSON, select the first authorization_servers entry, and attempt authorization-server discovery (oauth-authorization-server → openid-configuration → computed defaults). Introduces helper functions for header parsing and protected-resource fetching.
RFC 9728 Discovery Test Coverage
client/transport/oauth_test.go
Adds tests that spin up servers returning 401 with WWW-Authenticate containing resource_metadata (including whitespace variants), serve the referenced protected-resource JSON and authorization-server metadata, and assert discovery flow and metadata population.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

Possibly related PRs

Suggested labels

type: bug, status: needs submitter response

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is incomplete. It lacks required sections like Type of Change, Checklist, and detailed validation information specified in the template. Complete the description by adding Type of Change checkboxes, Checklist verification items, and filling in the Validation section with specific validation steps performed.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding RFC 9728 fallback support for resource_metadata parameter from WWW-Authenticate headers in OAuth discovery.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth.go`:
- Around line 528-558: In extractResourceMetadataURL, the parsing only finds the
exact substring "resource_metadata=" and thus misses cases with optional
whitespace; update the logic to robustly parse auth-params from
wwwAuthenticateHeaders by splitting each header into comma-separated params,
trimming spaces, locating a param whose key (after trimming) equals
"resource_metadata" and then extracting its value (handling optional surrounding
quotes and trimming whitespace); operate on the wwwAuthenticateHeaders slice and
ensure you handle values with/without quotes and ignore empty values so
resource_metadata is correctly returned even when whitespace surrounds the '='.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 879c3ec9-590f-4ae7-a89a-34886bcd2a87

📥 Commits

Reviewing files that changed from the base of the PR and between 2e8a408 and 2471bcf.

📒 Files selected for processing (2)
  • client/transport/oauth.go
  • client/transport/oauth_test.go

Comment thread client/transport/oauth.go
Comment on lines +407 to +418
authMetadataURL, err := buildWellKnownURL(authServerURL, "oauth-authorization-server")
if err == nil {
h.fetchMetadataFromURL(ctx, authMetadataURL)
if h.serverMetadata != nil {
return
}
}

openidMetadataURL, err := buildWellKnownURL(authServerURL, "openid-configuration")
if err == nil {
h.fetchMetadataFromURL(ctx, openidMetadataURL)
if h.serverMetadata != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't latch the first metadata error in this fallback chain.

Lines 409-417 call fetchMetadataFromURL, which writes h.metadataFetchErr immediately. If oauth-authorization-server fails with a transport/decode error but openid-configuration succeeds, h.serverMetadata gets populated but getServerMetadata() still returns the stale first error. That breaks this new RFC 9728 fallback whenever the first metadata document is malformed but a later fallback is usable. Please keep per-attempt errors local and only assign metadataFetchErr after all fallbacks fail.

Comment thread client/transport/oauth.go
@ezynda3

ezynda3 commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Hi @Gujiassh. Thanks for the PR, could you have a look at and address the coderabbit comments?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/transport/oauth_test.go (1)

990-1088: Refactor the two header-variant tests into one table-driven test.

These cases differ only by header formatting and are a strong fit for a tests := []struct{...} loop.

As per coding guidelines, "**/*_test.go`: Testing: ... Write table-driven tests using a tests := []struct{ name, ... } pattern".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth_test.go` around lines 990 - 1088, Combine
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader and
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace into
one table-driven test: create a tests := []struct{name string; headerValue
string} with two entries for the header without and with whitespace, then loop
over them and run each case (t.Run(tc.name,...)) setting the WWW-Authenticate
header to tc.headerValue inside the httptest server; keep the same assertions
and reuse NewOAuthHandler, handler.SetBaseURL, and handler.GetServerMetadata to
verify protectedResourceRequested, headerResourceMetadataRequested,
authServerRequested and the returned metadata.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/oauth_test.go`:
- Around line 990-1088: The tests
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader and
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace
currently only assert that endpoints were hit; to harden them, record the
sequence of requested paths (e.g., append r.URL.Path to a requestOrder slice
inside the httptest.Server handler) and after calling
handler.GetServerMetadata(ctx) assert that requestOrder equals the exact
expected sequence ["/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/googledrive",
"/.well-known/oauth-authorization-server/oauth/googledrive"] to enforce
precedence (protected-resource → header resource_metadata URL → derived
auth-server metadata); update both tests and keep existing boolean flags and
metadata assertions.

---

Nitpick comments:
In `@client/transport/oauth_test.go`:
- Around line 990-1088: Combine
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader and
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace into
one table-driven test: create a tests := []struct{name string; headerValue
string} with two entries for the header without and with whitespace, then loop
over them and run each case (t.Run(tc.name,...)) setting the WWW-Authenticate
header to tc.headerValue inside the httptest server; keep the same assertions
and reuse NewOAuthHandler, handler.SetBaseURL, and handler.GetServerMetadata to
verify protectedResourceRequested, headerResourceMetadataRequested,
authServerRequested and the returned metadata.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 78559602-c97b-4fe6-96ab-8f15777ca7de

📥 Commits

Reviewing files that changed from the base of the PR and between 2471bcf and 6c05322.

📒 Files selected for processing (2)
  • client/transport/oauth.go
  • client/transport/oauth_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/transport/oauth.go

Comment on lines +990 to +1088
func TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader(t *testing.T) {
protectedResourceRequested := false
headerResourceMetadataRequested := false
authServerRequested := false

var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource":
protectedResourceRequested = true
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_request", resource_metadata="`+server.URL+`/.well-known/oauth-protected-resource/googledrive"`)
w.WriteHeader(http.StatusUnauthorized)
case "/.well-known/oauth-protected-resource/googledrive":
headerResourceMetadataRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthProtectedResource{
AuthorizationServers: []string{server.URL + "/oauth/googledrive"},
})
case "/.well-known/oauth-authorization-server/oauth/googledrive":
authServerRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(AuthServerMetadata{
Issuer: server.URL + "/oauth/googledrive",
AuthorizationEndpoint: server.URL + "/oauth/googledrive/authorize",
TokenEndpoint: server.URL + "/oauth/googledrive/token",
RegistrationEndpoint: server.URL + "/oauth/googledrive/register",
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

handler := NewOAuthHandler(OAuthConfig{
ClientID: "test-client",
RedirectURI: "http://localhost/callback",
TokenStore: NewMemoryTokenStore(),
})
handler.SetBaseURL(server.URL)

metadata, err := handler.GetServerMetadata(context.Background())
require.NoError(t, err)
assert.True(t, protectedResourceRequested)
assert.True(t, headerResourceMetadataRequested)
assert.True(t, authServerRequested)
assert.Equal(t, server.URL+"/oauth/googledrive", metadata.Issuer)
assert.Equal(t, server.URL+"/oauth/googledrive/authorize", metadata.AuthorizationEndpoint)
assert.Equal(t, server.URL+"/oauth/googledrive/token", metadata.TokenEndpoint)
}

func TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace(t *testing.T) {
protectedResourceRequested := false
headerResourceMetadataRequested := false
authServerRequested := false

var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource":
protectedResourceRequested = true
w.Header().Add("WWW-Authenticate", `Bearer error="invalid_request", resource_metadata = "`+server.URL+`/.well-known/oauth-protected-resource/googledrive"`)
w.WriteHeader(http.StatusUnauthorized)
case "/.well-known/oauth-protected-resource/googledrive":
headerResourceMetadataRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthProtectedResource{
AuthorizationServers: []string{server.URL + "/oauth/googledrive"},
})
case "/.well-known/oauth-authorization-server/oauth/googledrive":
authServerRequested = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(AuthServerMetadata{
Issuer: server.URL + "/oauth/googledrive",
AuthorizationEndpoint: server.URL + "/oauth/googledrive/authorize",
TokenEndpoint: server.URL + "/oauth/googledrive/token",
RegistrationEndpoint: server.URL + "/oauth/googledrive/register",
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

handler := NewOAuthHandler(OAuthConfig{
ClientID: "test-client",
RedirectURI: "http://localhost/callback",
TokenStore: NewMemoryTokenStore(),
})
handler.SetBaseURL(server.URL)

metadata, err := handler.GetServerMetadata(context.Background())
require.NoError(t, err)
assert.True(t, protectedResourceRequested)
assert.True(t, headerResourceMetadataRequested)
assert.True(t, authServerRequested)
assert.Equal(t, server.URL+"/oauth/googledrive", metadata.Issuer)
assert.Equal(t, server.URL+"/oauth/googledrive/authorize", metadata.AuthorizationEndpoint)
assert.Equal(t, server.URL+"/oauth/googledrive/token", metadata.TokenEndpoint)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Harden this regression by asserting exact discovery sequence.

Right now, the tests only check that key endpoints were hit. They don’t fail if default fallback endpoints are called unnecessarily. Capture requested paths and assert strict order to lock in precedence (protected-resource → header resource_metadata URL → derived auth-server metadata).

🔎 Suggested test hardening
+    requestedPaths := make([]string, 0, 4)
     server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+        requestedPaths = append(requestedPaths, r.URL.Path)
         switch r.URL.Path {
         case "/.well-known/oauth-protected-resource":
             ...
         case "/.well-known/oauth-protected-resource/googledrive":
             ...
         case "/.well-known/oauth-authorization-server/oauth/googledrive":
             ...
         default:
             w.WriteHeader(http.StatusNotFound)
         }
     }))
 ...
+    assert.Equal(t, []string{
+        "/.well-known/oauth-protected-resource",
+        "/.well-known/oauth-protected-resource/googledrive",
+        "/.well-known/oauth-authorization-server/oauth/googledrive",
+    }, requestedPaths)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/oauth_test.go` around lines 990 - 1088, The tests
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader and
TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace
currently only assert that endpoints were hit; to harden them, record the
sequence of requested paths (e.g., append r.URL.Path to a requestOrder slice
inside the httptest.Server handler) and after calling
handler.GetServerMetadata(ctx) assert that requestOrder equals the exact
expected sequence ["/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/googledrive",
"/.well-known/oauth-authorization-server/oauth/googledrive"] to enforce
precedence (protected-resource → header resource_metadata URL → derived
auth-server metadata); update both tests and keep existing boolean flags and
metadata assertions.

@ezynda3

ezynda3 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Hey @Gujiassh — heads up, two more PRs have landed after yours implementing the same RFC 9728 §5.1 resource_metadata feature: #804 (Apr 15, @MariaChrysafis) and #808 (Apr 18, @Dennisadira). I posted a detailed comparison of those two on #808 here: #808 (comment)

You were first to tackle this, so I want to make sure you're in the loop. A few notes on how this PR compares:

Different trigger point — this PR parses WWW-Authenticate from the response to the .well-known/oauth-protected-resource endpoint when it returns non-200. The RFC 9728 §5.1 flow is a bit different: the resource_metadata hint comes from the resource server itself (the MCP endpoint) on a 401, not from the well-known endpoint. #804 and #808 both hook into the transport-level 401 handling in SSE and StreamableHTTP, which matches the spec's intended flow more closely. Your approach would only fire if the well-known endpoint itself happened to return a 401 with that header, which is an unusual server behavior.

Parser — the strings.Split + strings.Cut approach here is the simplest of the three, but it can break on valid WWW-Authenticate values where commas separate parameters within a single challenge (e.g. Bearer realm="mcp", error="invalid_token", resource_metadata="..."). #804 has a full RFC 9110 §11.6.1 challenge parser ported from the official go-sdk that handles these cases.

Missing from all three but present in others:

Given that #804 and #808 are both more complete and there's already a conversation happening about combining the best parts of those two, it probably makes sense to close this one in favor of whichever combined PR comes out of that discussion. Your early work on this clearly helped frame the problem though — the test scenarios you wrote (especially the whitespace-in-header variant) are useful validation.

@ezynda3

ezynda3 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Update: #730 by @sd2k predates all the PRs here and has the most complete design. I'm planning to merge that one (once rebased + security validations from #808 are added) and close this PR, #804, and #808. Thanks for the work on this — see #730 for the path forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: duplicate This issue or pull request already exists

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants